<?php

namespace Yolo\IdGenerator;

use Exception;

/**
 * Snowflake algorithm implementation in PHP
 * @author <LUSHAOMING> <lushaoming6@gmail.com>
 * @date 2024-12-06
 */
class Snowflake implements IdGeneratorInterface
{
    private int $epoch = 1704038400000;
    private int $workerId;
    private int $datacenterId;
    private int $sequence = 0;
    private int $lastTimestamp = -1;

    const WORKER_ID_BITS = 5;
    const DATACENTER_ID_BITS = 1;
    const SEQUENCE_BITS = 8; // 每毫秒能生成256个ID

    const MAX_WORKER_ID = -1 ^ (-1 << self::WORKER_ID_BITS);
    const MAX_DATACENTER_ID = -1 ^ (-1 << self::DATACENTER_ID_BITS);
    const MAX_SEQUENCE = -1 ^ (-1 << self::SEQUENCE_BITS);

    const WORKER_ID_SHIFT = self::SEQUENCE_BITS;
    const DATACENTER_ID_SHIFT = self::SEQUENCE_BITS + self::WORKER_ID_BITS;
    const TIMESTAMP_LEFT_SHIFT = self::SEQUENCE_BITS + self::WORKER_ID_BITS + self::DATACENTER_ID_BITS;

    /**
     * @throws Exception
     */
    public function __construct(array $config = [])
    {
        if ($config['workerId'] > self::MAX_WORKER_ID || $config['workerId'] < 0) {
            throw new Exception("Worker ID can't be greater than " . self::MAX_WORKER_ID . " or less than 0");
        }
        if ($config['datacenterId'] > self::MAX_DATACENTER_ID || $config['datacenterId'] < 0) {
            throw new Exception("Datacenter ID can't be greater than " . self::MAX_DATACENTER_ID . " or less than 0");
        }

        $this->workerId = $config['workerId'];
        $this->datacenterId = $config['datacenterId'];
        $this->epoch = strtotime($config['epoch']) * 1000;
    }

    /**
     * @return string
     * @throws Exception
     */
    public function generateId(): string
    {
        $timestamp = $this->getCurrentMillis();

        if ($timestamp < $this->lastTimestamp) {
            throw new Exception("Clock moved backwards. Refusing to generate id for " . ($this->lastTimestamp - $timestamp) . " milliseconds");
        }

        if ($this->lastTimestamp == $timestamp) {
            $this->sequence = ($this->sequence + 1) & self::MAX_SEQUENCE;
            if ($this->sequence == 0) {
                $timestamp = $this->tilNextMillis($this->lastTimestamp);
            }
        } else {
            $this->sequence = 0;
        }

        $this->lastTimestamp = $timestamp;

        return (string)(($timestamp - $this->epoch) << self::TIMESTAMP_LEFT_SHIFT)
            | ($this->datacenterId << self::DATACENTER_ID_SHIFT)
            | ($this->workerId << self::WORKER_ID_SHIFT)
            | $this->sequence;
    }

    private function tilNextMillis($lastTimestamp): float
    {
        $timestamp = $this->getCurrentMillis();
        while ($timestamp <= $lastTimestamp) {
            $timestamp = $this->getCurrentMillis();
        }
        return $timestamp;
    }

    private function getCurrentMillis(): float
    {
        return round(microtime(true) * 1000);
    }
}
